今天我們把前幾天的成果串起來,完成 選股工具的資料基礎建設:
IStockApiService 抓取股票的 日 K 線資料(近一年或增量補齊)。以下是最新版本的 Repository,專責管理:
// Repositories/LiteDbStockRepository.cs
using LiteDB;
using MyStockApp.Model;
using WpfTestApp.Model;
public class LiteDbStockRepository : IStockRepository
{
    private readonly string _dbPath;
    public LiteDbStockRepository(string dbPath = "StockData.db")
    {
        _dbPath = dbPath;
    }
    public void SaveStocks(IEnumerable<StockProfile> stocks)
    {
        using var db = new LiteDatabase(_dbPath);
        var col = db.GetCollection<StockProfile>("stocks");
        col.DeleteAll();
        col.InsertBulk(stocks);
    }
    public IEnumerable<StockProfile> LoadStocks()
    {
        using var db = new LiteDatabase(_dbPath);
        return db.GetCollection<StockProfile>("stocks").FindAll();
    }
    public DateTime? LoadLatestQuoteDate(string code)
    {
        using var db = new LiteDatabase(_dbPath);
        var col = db.GetCollection<DailyQuote>("daily_quotes");
        var last = col.Find(x => x.Code == code)
                      .OrderByDescending(x => x.Date)
                      .FirstOrDefault();
        return last?.Date;
    }
    public void UpsertDailyQuotes(string code, IEnumerable<DailyQuote> quotes)
    {
        using var db = new LiteDatabase(_dbPath);
        var col = db.GetCollection<DailyQuote>("daily_quotes");
        col.EnsureIndex(x => new { x.Code, x.Date }, unique: true);
        foreach (var q in quotes)
        {
            col.Upsert(q);
        }
    }
    public void UpsertMissingDays(string code, IEnumerable<MissingQuoteDay> items)
    {
        using var db = new LiteDatabase(_dbPath);
        var col = db.GetCollection<MissingQuoteDay>("missing_days");
        col.EnsureIndex(x => new { x.Code, x.Date }, unique: true);
        foreach (var item in items)
        {
            col.Upsert(item);
        }
    }
    public IEnumerable<DailyQuote> LoadDailyQuotes(string code, DateTime from, DateTime to)
    {
        using var db = new LiteDatabase(_dbPath);
        return db.GetCollection<DailyQuote>("daily_quotes")
                 .Find(x => x.Code == code && x.Date >= from && x.Date <= to)
                 .OrderBy(x => x.Date);
    }
    public void UpsertIndicators(string code, IEnumerable<TechIndicator> items)
    {
        using var db = new LiteDatabase(_dbPath);
        var col = db.GetCollection<TechIndicator>("indicators");
        col.DeleteMany(x => x.Code == code && x.Date >= DateTime.UtcNow.AddYears(-1));
        col.InsertBulk(items);
        col.EnsureIndex(x => new { x.Code, x.Date });
    }
    public TechIndicator? LoadIndicator(string code, DateTime date)
    {
        using var db = new LiteDatabase(_dbPath);
        var col = db.GetCollection<TechIndicator>("indicators");
        return col.Find(x => x.Code == code && x.Date <= date)
                  .OrderByDescending(x => x.Date)
                  .FirstOrDefault();
    }
}
將原本Day 12做的在 ICrawler 的方法改個名稱IStockApiService:
    /// <summary>
    /// Data crawler capable of fetching stock lists and historical quotes.
    /// 資料擷取介面:用於取得股票清單與歷史報價。
    /// </summary>
    public interface 的方法改個名稱IStockApiService
    {
        /// <summary>
        /// Retrieves all stock profiles.
        /// 取得所有股票主檔資料。
        /// </summary>
        IEnumerable<StockProfile> GetStockList();
        /// <summary>
        /// Crawls historical daily quotes for a single stock.
        /// 取得單一股票於指定期間的每日行情。
        /// </summary>
        /// <param name="code">Stock code.</param>
        /// <param name="startDate">Inclusive start date.</param>
        /// <param name="endDate">Inclusive end date.</param>
        IEnumerable<DailyQuote> GetDailyQuotes(string code, DateTime startDate, DateTime endDate);
    }
}
實作的部分,這邊建議使用FinMind或Fugule的API來取得所有股票的基本資料,因TWSE(台灣證交所)的API資料只有上市公司的股票,並未包含上櫃(要去TPEX的API取),使用上FinMind或Fugule的API較為方便
這個服務會:
public class QuoteFetchService : IQuoteFetchService
{
    private readonly IStockApiService _api;
    private readonly IStockRepository _repo;
    public QuoteFetchService(IStockApiService api, IStockRepository repo)
    {
        _api = api;
        _repo = repo;
    }
    public Task<int> FetchLastYearAsync(CancellationToken ct = default)
    {
        int updated = 0;
        var stocks = _repo.LoadStocks();
        var today = DateTime.UtcNow.Date;
        foreach (var s in stocks)
        {
            ct.ThrowIfCancellationRequested();
            var latest = _repo.LoadLatestQuoteDate(s.Code);
            DateTime from = latest?.AddDays(1) ?? today.AddYears(-1);
            DateTime to = today;
            if (from > to) continue;
            var quotes = _api.GetDailyQuotes(s.Code, from, to).OrderBy(q => q.Date).ToList();
            if (quotes.Count > 0)
            {
                _repo.UpsertDailyQuotes(s.Code, quotes);
                updated++;
            }
            var expected = EnumerateBusinessDays(from, to).ToHashSet();
            var returned = quotes.Select(q => q.Date.Date).ToHashSet();
            var missing = expected.Except(returned)
                                  .Select(d => new MissingQuoteDay
                                  {
                                      Code = s.Code,
                                      Date = d,
                                      Reason = "NoQuoteReturned"
                                  });
            _repo.UpsertMissingDays(s.Code, missing);
        }
        return Task.FromResult(updated);
    }
    private static IEnumerable<DateTime> EnumerateBusinessDays(DateTime from, DateTime to)
    {
        for (var d = from.Date; d <= to.Date; d = d.AddDays(1))
        {
            if (d.DayOfWeek != DayOfWeek.Saturday && d.DayOfWeek != DayOfWeek.Sunday)
                yield return d;
        }
    }
}
IndicatorService 提供 BuildIndicators,根據日 K 計算 KD / MACD。
(程式碼與前一篇相同,這裡不再重複,重點是:算出來的結果存回 DB → UpsertIndicators。)
今天完成了:
到這裡,我們已經有一個完整的「資料流」:
API → Repository → DB → 指標計算。